Skip to content

[cDAC][wasm] Decode R2R variable locations and expose function identity - #133890

Draft
lewing wants to merge 14 commits into
dotnet:mainfrom
lewing:lewing-r2r-cdac-for-wasm
Draft

lewing wants to merge 14 commits into
dotnet:mainfrom
lewing:lewing-r2r-cdac-for-wasm

Conversation

@lewing

@lewing lewing commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Decode ReadyToRun WebAssembly variable locations through cDAC while keeping wasm unwind state and function identity in the shared StackWalk contract.

  • Maintain coherent WASM SP/IP/logical-FP context across R2R unwind and explicit Frame seeding.
  • Decode packed wasm register locations as symbolic WasmLocal(Index, WasmDebugValueType) / WasmLocalPair values.
  • Resolve VLT_STK from the logical frame pointer already stored in the frame context.
  • Expose module-qualified R2R function identity through IStackWalk.
  • Preserve packed ICorDebugInfo::VarLoc data through DacDbi.
  • Distinguish engine-private locals, unreadable/null byref indirections, and readable stack-homed null references.

Temporary stack

Important

This draft depends on both #133917 and #133086. Their head branches live in the lewing/runtime fork, so this upstream PR cannot target either branch directly and temporarily contains both lower layers. After those PRs merge, their commits will disappear when this branch is rebased onto main.

Lower-layer heads:

The temporary history is exactly:

  1. Five [cDAC][wasm] Resolve ReadyToRun virtual IP ranges #133917 virtual-IP commits through 443fb559f47.
  2. Six [wasm] Preserve R2R variable debug information #133086 producer commits through replayed a7a349ecf4d.
  3. Three consumer commits:
    • 756dd2055b8 — [cdac][wasm] Maintain frame context and expose function identity
    • b9f7b830b04 — [cdac][wasm] Decode ReadyToRun variable locations
    • 11664425ab8 — [cdac][wasm] Handle unavailable variable values

Producer constants, encoding globals, optimized-record tests, GC specimens, and stack-base invariants remain owned by #133086. Virtual-IP descriptor/traversal and filter-funclet classification remain owned by #133917.

Corrected virtual-IP dependency

#133917 fixes the live runtime model that #130988's mock test did not represent: WASM ReadyToRun virtual IPs are registered in ExecutionManager::s_pVirtualIPRangeList, not RangeSectionMap.

This PR consumes that shared correction rather than adding a parallel lookup. The integrated reader:

  • resolves real virtual IP ranges before ordinary RangeSectionMap lookup;
  • masks the WASM funclet bit for runtime-function ordering/address arithmetic while retaining funclet identity;
  • keeps synthetic virtual entrypoint bases separate from loaded-image bases used for RVA reads;
  • uses the actual WASM descriptor shape;
  • resolves an executable FilterOffset to its containing runtime function before comparing the masked funclet start.

The last point reflects corrected #133917 head 443fb559f47. A filter entry may be inside the filter runtime function rather than exactly at its start; raw FilterOffset == funcletStart equality is intentionally not used.

StackWalk context and function identity

Native WASM RtlVirtualUnwind updates InterpreterSP, InterpreterIP, and the funclet-resolved InterpreterFP together. cDAC now maintains the same invariant:

  • R2R InlinedCallFrame marker INLINED_PINVOKE_FROM_R2R derives IP/FP from CallSiteSP rather than treating marker 1 as an IP.
  • TransitionFrame uses its saved R2R SP, lazily derives a zero return address, and derives FP only when the saved SP/IP are valid. The generic argument-area fallback is never parsed as a shadow frame.
  • Software exception frames copy the full serialized WASM context.
  • Reverse P/Invoke does not probe native caller bytes as a shadow frame. Caller SP is retained with IP/FP cleared.
  • A successful unwind into a non-R2R caller likewise retains caller SP with a null IP; a direct regression test pins this boundary.
  • WASM ReadyToRun GC info uses a dedicated Wasm32GcInfoEncoding reader; the target-advertised GCInfo contract no longer falls back to the default throwing implementation.

IStackWalk.GetWasmFunctionIdentity is valid only for ReadyToRun frameless frames and returns:

FunctionTableIndex      raw runtime-global shared-table index from the frame
Module?                 owning R2R Module
RuntimeFunctionIndex?   RUNTIME_FUNCTION index within that image
IsFunclet?              nullable classification when the range/entry is unresolved

Runtime table indices are globally relocated by each module's tableBase, but V8 func_index values are module-local. Consumers therefore need the owning module plus image-relative runtime-function index to select the correct script and translate through its element section.

Logical frame pointer and stack locations

Current producer stack records encode base register 2; REG_FPBASE, REG_SPBASE, and REGNUM_AMBIENT_SP collapse to that value on WASM. The record identifies a logical frame-relative home, not a particular V8 local.

The absolute frame pointer is reconstructed from shadow-stack memory by the shared unwinder:

  • root without localloc: logical FP aliases SP;
  • root with localloc: logical FP remains the fixed pre-adjustment frame base;
  • funclet: logical FP is the parent/establishing method frame.

Measured engine-local indices ($0, $1, $3) are current codegen observations, not cDAC format or API. No FP-local metadata is required. Variable resolution consumes WasmContext.FramePointer.

Variable locations

RyuJIT packs a local index and JIT debug value type into the 32-bit RegNum payload. cDAC receives the encoding from target globals:

  • WasmDebugRegisterTypeShift
  • WasmDebugValueTypeCount

A non-empty WASM Vars stream with missing, mismatched, impossible, or unknown encoding metadata fails explicitly. Invalid packed registers do not fall back to native register locations.

The contract reports:

  • VLT_REG / VLT_REG_BYREF as WasmLocal;
  • VLT_REG_REG as WasmLocalPair;
  • stack-based kinds as linear-memory locations resolved from the logical context FP.

For WASM, GetMethodVarInfo uses the ReadyToRun CodeBlock's controlling-method-relative ExecutionManager.GetRelativeOffset result. It does not route portable-entrypoint MethodDescs through the generic CodeVersions validation path.

WasmDebugValueType is JIT debug-encoding vocabulary, not the full stable WebAssembly specification type set. Managed references currently use the JIT's machine I32/I64 representation.

Unavailable and null values

Three cases remain intentionally distinct:

  1. Engine-private WASM local: zero physical locations; GetBytes/GetAddress fail with E_NOINTERFACE.
  2. Unreadable or null VLT_STK_BYREF indirection: one logical location remains, matching native location count, while address/value/object access fails with CORDBG_E_READVIRTUAL_FAILURE.
  3. Direct stack-homed null reference: one readable location; reading its bytes succeeds and returns zero.

The native DAC converts a failed byref read to address zero and subsequently fails because native address zero is unmapped. WASM linear address zero is readable, so carrying that fallback forward would fabricate a successful unrelated value. Non-WASM behavior is unchanged.

Producer validation from #133086

The producer now has exact MinOpts and FullOpts coverage. This is not a minopts-only feature, and optimized-away locals are explicitly absent rather than required to appear.

Representative exact records include:

GcSlotIdentity
local0 [0x36,0x2B1) VLT_STK base=2 offset=0x48  GcMarker(Value=17)
local1 [0x36,0x2B1) VLT_STK base=2 offset=0x44  GcMarker(Value=29)
local2 [0x36,0x2B1) VLT_STK base=2 offset=0x40  legitimate null
local3 [0x36,0x2B1) VLT_STK base=2 offset=0x3C

The reference slots are producer-verified GC_FRAMEREG_REL, pinned/untracked roots across an actual GC. Their common method-wide range is a documented current producer limitation; this PR does not claim per-local range-end fidelity.

Live validation on the final combined stack

A fresh browser clr+libs build from the equivalent target-runtime tree at 7e72c0e0d16 completed with zero warnings/errors. The later 11664425ab8 changes are external managed-reader fixes only and do not change the browser target bytes. The exact optimized browser module was then published and hashed:

Wasm.Browser.Sample.wasm
SHA-256 72146a260144a10b9069037245991cc6d29b78ddbe230aa435c07c8093a874e6
GcSlotIdentity V8 function index 22
post-GC breakpoint 0x140A (immediately after call_indirect at 0x1407)

The live cDAC/CDP join passed:

raw global function-table index 25449
owning Module                0x413FE24
image runtime-function index 20
IsFunclet                    false
root SP / logical FP         0x1FD5F0
synthetic shadow offset      0x2
producer descriptor offset   0x18C

local0 [0x36,0x2B1) base=2 +0x48
  slot   0x1FD638
  object 0x9AF4E8
  type   Webcil.WasmWebcilModule+GcMarker
  Value  17

local1 [0x36,0x2B1) base=2 +0x44
  slot   0x1FD634
  object 0x9AF4F4
  type   Webcil.WasmWebcilModule+GcMarker
  Value  29

local2 [0x36,0x2B1) base=2 +0x40
  one physical location
  object reference 0
  successful bytes 00 00 00 00

The two marker slots and object references were distinct. Swapping the +0x48/+0x44 associations preserved the plausible same type but failed the exact 17/29 value oracle. The engine-local control had zero locations and GetBytes == E_NOINTERFACE. The pause occurred after the actual GC.Collect call returned, and both objects remained reachable with their expected type/value.

This run discovered and fixed two reader gaps before passing:

  1. CoreCLR advertised GCInfo on WASM, but cDAC registered the default throwing implementation. Wasm32GCInfoTraits now mirrors native Wasm32GcInfoEncoding. Removing the registration makes the exact decoder test fail with NotImplementedException.
  2. GetMethodVarInfo routed WASM portable-entrypoint MethodDescs through generic CodeVersions validation. It now consumes the controlling-method-relative offset already computed by ExecutionManager. Restoring the generic path makes the exact test fail because CodeVersions is not advertised/valid for this target shape.

The R2R shadow frame's synthetic offset (0x2) is not the producer DebugInfo native offset (0x18C). The live validation therefore uses cDAC to resolve the method and descriptors, then applies the producer's exact pause-offset oracle. A general CDP byte-offset → JIT DebugInfo offset translation remains a consumer/tooling follow-up; no mapping is fabricated here.

The older PrintMeaning and SumWithFinally runs remain supporting evidence for engine-local and funclet-parent-FP behavior. They did not exercise filter-funclet classification; corrected #133917 filter behavior remains covered by focused cDAC tests.

Tests after corrected restack

  • Exact filter-funclet test group: 3 passed, 0 failed.
  • Focused final WASM/live-boundary group: 48 passed, 0 failed.
  • cDAC UnitTests: 3,234 passed, 0 failed, 0 skipped.
  • Final browser clr+libs build: 0 warnings, 0 errors.
  • tools.cdactests:
    • UnitTests: passed;
    • DataGeneratorTests: passed;
    • UsageTests/generated documentation: passed.
  • Generated StackWalk.md usage section was refreshed and verified current.
  • Post-restack code review found one missing null-IP boundary test/documentation issue; both were added before the final green run.

Mutation evidence includes packed-register mismatch, unavailable-value regression, frame-kind identity gating, stale funclet FP, C3 DereferenceOrZero fallback, swapped same-type C2 slots, missing WASM GC-info registration, restoring generic CodeVersions offset resolution, and #133917's filter-entry raw-equality mutation.

Limitations and readiness

The corrected lower-layer integration and requested live C2 validation are complete. This PR remains draft pending lower-PR readiness, CI disposition, and Larry's decision; it is not being marked ready by this update.

Note

This pull request description was generated with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 7 pipeline(s).
9 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@lewing lewing added the arch-wasm WebAssembly architecture label Sep 14, 2026
@lewing lewing changed the title [cDAC][wasm] Decode R2R variable locations and expose function identity [wip][cDAC][wasm] Decode R2R variable locations and expose function identity Sep 14, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

@lewing
lewing force-pushed the lewing-r2r-cdac-for-wasm branch 2 times, most recently from d5f26e6 to 69ad34f Compare September 14, 2026 18:53
Expose WebAssembly ReadyToRun virtual IP ranges through the runtime data descriptor and resolve them before the RangeSectionMap. Mask funclet flags and keep virtual code identity separate from loaded-image RVA reads.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing and others added 2 commits September 14, 2026 19:25
Handle feature-gated hot/cold metadata, isolate candidate module validation during registration, and remove the unsupported virtual-IP list length limit. Match WASM descriptor layouts and cover long lists, partial registration, and root/funclet image metadata.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Allow up to 65,536 nodes while retaining full-list ambiguity and cycle checks. Reject an over-budget chain before reading its next node, even after a match. Document the budget as reader policy and cover exact-budget success, budget+1 rejection, and the read boundary with a compact fixture.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@lewing lewing changed the title [wip][cDAC][wasm] Decode R2R variable locations and expose function identity [cDAC][wasm] Decode R2R variable locations and expose function identity Sep 15, 2026
lewing and others added 8 commits September 15, 2026 13:41
Cover the shared native and cDAC invariant that a filter clause offset equals the flagged funclet start relative to its controlling method.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Map the executable filter entry to its containing runtime function before comparing funclet starts, matching native WASM behavior while preserving non-WASM offset comparisons.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Preserve variable debug information produced by RyuJIT for ReadyToRun WebAssembly code, including scope ranges across relooper block ordering and packed wasm local register locations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The end-to-end variable-debug-info validation exposed the hidden wasm
portable-entry-pointer argument as a source local. The argument is appended
after user arguments, but unlike the wasm stack-pointer argument it was not
recorded or excluded by compMap2ILvarNum. AddDoubles therefore reported the
hidden i32 argument as source local 0, alongside the real f64 parameters.

Record the argument's local number when it is created, map it to
UNKNOWN_ILNUM, and account for it when mapping later internal locals back to
IL variable numbers.

Replace the count-only wasm R2R checks with complete exact records for the
AddDoubles parameters and SumWithFinally local: variable identity, native
range, location kind, packed wasm local, and frame-pointer-relative offset.
Mutate the local-index bits of one packed register and prove the exact oracle
rejects the corrupted record.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Define the packed WASM debug-register bit layout in ICorDebugInfo and have
the JIT derive its register masks from that shared encoding contract. Assert
that the JIT register representation and WasmValueType count remain
compatible with the debug-info format.

Document that the static ReadyToRun reader's compiled-in shift must move
with a versioned R2R debug-info format change, since it has no live target
descriptor from which to discover a different layout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Publish the shared WASM register type shift and value-type count through the target data descriptor so version-skewed readers can reject incompatible variable debug information.

Document the producer-owned encoding and extend the static ReadyToRun reader coverage for reserved and unsupported value-type codes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add a no-opt object local that remains live across a GC call in a finally funclet. Assert its IL class type, complete ReadyToRun variable tuples, frame-relative GC slot, and safepoint coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add exact optimized tracked-variable coverage, frame-base ABI variations including localloc and funclets, and same-type GC slot identity with a legitimate null reference.

Pin the current stack VarLoc base encoding and document that absolute frame reconstruction is independent of unstable wasm local indices.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing and others added 2 commits September 25, 2026 14:49
Keep the WASM stack-walk context's SP, virtual IP, and logical frame pointer
coherent with the native RtlVirtualUnwind and Frame::UpdateRegDisplay paths.

- Derive IP and logical FP for the R2R InlinedCallFrame marker.
- Use TransitionBlock's saved R2R SP, including lazy return-address recovery,
  and avoid treating the generic fallback argument area as a shadow frame.
- Copy the full serialized WASM context for software exception frames.
- Detect reverse P/Invoke from GC info so native caller bytes are never probed
  as a possible R2R shadow frame; retain caller SP with IP/FP cleared.
- Expose a documented StackWalk function identity containing the raw shared
  table index, owning module, image runtime-function index, and nullable
  funclet classification. Reject native/interpreter handles before reading
  shadow-frame memory while preserving a known raw index when range lookup
  itself fails.

Add native-layout transition fixtures, real adjacent root/funclet range
coverage, parent/nested/terminator/localloc unwind coverage, and false-frame
reverse-P/Invoke tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Decode the WASM register representation produced by ReadyToRun debug info as
symbolic WasmLocal/WasmLocalPair locations with an encoding-specific value
type. Engine-owned locals remain symbolic; stack locations resolve from the
logical frame pointer already maintained by the StackWalk context.

Advertise the register type shift and supported value count as WASM cDAC data
descriptor globals. Require and validate those values before reading a
non-empty variable stream so a reader/target format mismatch fails explicitly
instead of plausibly decoding the wrong local or type. Reject malformed packed
registers and impossible shift/count combinations.

Keep current stack encoding exact: base register 2 means context FP. Remove
speculative decoded stack-base locals whose values the reader could not honor.
Preserve the original packed VarLoc through DacDbi for consumers that need the
ICorDebugInfo representation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep engine-private WASM locals unavailable rather than fabricating a zero
value, and distinguish an unreadable VLT_STK_BYREF pointer from a legitimate
stack-homed null reference.

The native DAC retains one logical location when indirect pointer reads fail,
but its subsequent address-zero memory read fails on native platforms. WASM
linear address zero is readable, so carrying that fallback forward can return
successful irrelevant bytes. Preserve the logical location count while making
all address/value/object access report CORDBG_E_READVIRTUAL_FAILURE when the
indirection is unreadable or resolves to null.

Non-WASM dereference behavior remains unchanged. Exact controls cover an
unreadable byref, a null byref pointer, a readable null reference, and a
symbolic engine local with no physical location.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@lewing
lewing force-pushed the lewing-r2r-cdac-for-wasm branch from 7e72c0e to 1166442 Compare September 25, 2026 19:52
lewing added a commit that referenced this pull request Sep 26, 2026
…ish (#134689)

## Summary

Pass `--strip-debug-info` to crossgen2 for CoreCLR browser-wasm
ReadyToRun publish. This drops the R2R `DebugInfo` section (native-to-IL
offset maps and variable locations) from shipped images. Opt out with
`PublishReadyToRunStripDebugInfo=false` to keep the data for debugging
R2R code (e.g. the cDAC work in #133086 / #133890).

Also makes crossgen2 argument changes invalidate per-app R2R images.
`_CreateR2RImages` only tracks file inputs, so toggling
`PublishReadyToRunStripDebugInfo` (or any
`PublishReadyToRunCrossgen2ExtraArgs` change) previously left stale
images in `obj/R2R`. The arguments are now written to
`obj/wasm-r2r-args.stamp` (only when different) and added to
`_ReadyToRunCompilerInputs`, mirroring the existing P/Invoke manifest
input.

> [!IMPORTANT]
> Stacked on #134618, which rewrites the same targets file. Retarget to
`main` after it merges.

## Size impact

Per-assembly R2R images compiled directly with crossgen2 using the SDK's
browser-wasm arguments (`--obj-format:wasm --opt-cross-module:*
--codegenopt:JitWasm*NyiToR2RUnsupported=1`), with and without
`--strip-debug-info`. Total for System.Private.CoreLib,
System.Text.Json, System.Linq, and System.Collections:

| Compiler | Raw saved | gzip -9 saved | brotli -q 11 saved |
| --- | --- | --- | --- |
| Current (#134618 base) | 859,712 B (2.4%) | 624,690 B (7.1%) | 565,366
B (9.4%) |
| With #133086 variable info | 2,105,520 B (5.6%) | 1,332,733 B (13.9%)
| 1,121,574 B (17.0%) |

<details>
<summary>Per-assembly brotli sizes (bytes, keep → strip)</summary>

| Assembly | Current | With #133086 |
| --- | --- | --- |
| System.Private.CoreLib | 4,822,231 → 4,347,541 | 5,331,799 → 4,403,849
|
| System.Text.Json | 823,314 → 761,533 | 887,982 → 754,444 |
| System.Linq | 219,177 → 200,471 | 238,853 → 200,441 |
| System.Collections | 119,635 → 109,446 | 130,742 → 109,068 |

The #133086 compiler is based on an older commit, so compare keep vs.
strip within a column rather than across columns.
</details>

## Validation

- MSBuild evaluation: `--strip-debug-info` is present by default, absent
with `PublishReadyToRunStripDebugInfo=false`, and absent when
`PublishReadyToRun` is off.
- Incremental harness: `_CreateR2RImages` runs on first build, skips
when unchanged, reruns on opt-out, skips when repeated, and reruns when
switching back.
- Not yet run: an end-to-end browser-wasm publish loading stripped
images in a browser (Wasm.Build.Tests in CI will cover this).

Runtime-pack framework R2R images (used only by the dev-loop build) are
unchanged; publish recompiles the whole closure through these targets.
`--strip-inlining-info` is intentionally not included: it removes
`CrossModuleInlineInfo`, and cross-module inlining is load-bearing on
wasm, so it needs separate validation.

> [!NOTE]
> This pull request was created with assistance from GitHub Copilot.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing added a commit that referenced this pull request Sep 26, 2026
## Summary

Fix cDAC live resolution of WebAssembly ReadyToRun virtual IPs by
matching the runtime's existing lookup model:

- expose `ExecutionManager::s_pVirtualIPRangeList` and
`VirtualIPRangeSection` through the data descriptor;
- resolve encoded virtual IPs through that intrusive list using cycle
detection and a 65,536-node per-lookup reader resource budget, with no
map fallback when an encoded VIP is absent;
- mask the WebAssembly funclet flag from `RUNTIME_FUNCTION.BeginAddress`
for ordering and address arithmetic while preserving funclet identity;
- keep the virtual code base (`MinVirtualIP`) separate from the
loaded-image base used for unwind, debug, GC, exception, and thunk RVA
reads;
- handle the actual WASM descriptor shape, where hot/cold metadata and
delay-load thunk metadata are absent;
- classify WASM filter funclets by mapping the executable filter entry
to its containing runtime function.

## Root cause

The model added in #130988 was already false when that PR merged. On
`TARGET_WASM`, ReadyToRun modules are not added to `RangeSectionMap`;
`ReadyToRunInfo::RegisterVirtualIPRange` registers them in
`ExecutionManager::s_pVirtualIPRangeList`, and native `FindCodeRange`
checks that list first.

The prior unit test synthesized a `RangeSectionMap` entry with an
address that did not satisfy native `IsVirtualIP`, so it validated a
mock-only model rather than the live runtime layout. This is a
test-model gap, not a reviewer fault. The prior review explicitly noted
that the WebAssembly specifics had not been run locally and should be
added to cDAC CI:
#130988 (review).

## Blast radius and scope

This affects ReadyToRun code on all CoreCLR WebAssembly hosts, including
browser and WASI. Interpreter code is unaffected.

The list lookup, descriptor feature gating, funclet masking, and
image-base separation are inseparable: exposing the list alone would
still throw while reading absent WASM fields, or could return the wrong
method or read RVA data from the synthetic virtual address space.

This PR is independent of #133086 and intentionally excludes variable
producer/decoder work. #133890 depends on this PR for correct shared
code lookup and function identity.

On WASM, `FilterOffset` is the executable filter entry and can follow a
synthetic funclet prolog. cDAC now mirrors the corrected native
classification in #133932 by resolving that entry to its containing
runtime function before comparing funclet starts. The PRs remain
independent; #133917 does not depend on changing the producer offset.

## Validation

- `./build.sh clr+libs+host`
- `PATH="/opt/homebrew/bin:$PATH" ./build.sh -os browser -c Debug
-subset clr+libs`
- cDAC UnitTests: **3162 passed**
- cDAC DataGeneratorTests: **46 passed**
- cDAC UsageTests: **4 passed**
- generated contract documentation check: **up to date**
- focused ExecutionManager / RuntimeFunction / WasmR2R tests: **221
passed**

The durable tests cover:

- captured/live-shaped VIP `0x80010109`, exact `MethodDesc`, module, and
runtime-function index;
- the actual WASM descriptor shape: 8-byte `RUNTIME_FUNCTION` records
with no `EndAddress`, and absent hot/cold and delay-load thunk fields;
- start/end boundaries and adjacent ranges;
- encoded VIP absent from the list with no `RangeSectionMap` fallback;
- self-cycle, two-node cycle, inverted range, null module, and
overlapping ambiguity;
- unrelated partially registered nodes not blocking initialized ranges,
while an uninitialized candidate fails closed;
- valid 1,024/1,025-node lists, exact 65,536-node budget success, and
budget+1 fail-closed behavior even when the head matches, with a read
counter proving the extra node is never dereferenced;
- root/funclet resolution with a flagged funclet entry that breaks raw
ordering;
- exact loaded-image debug, unwind, GC, and exception-clause reads while
entrypoint lookup uses `MinVirtualIP`;
- filter-funclet classification where `FilterOffset` follows the flagged
funclet start but resolves to the same containing runtime function;
- missing list capability and unchanged ordinary architecture behavior.

Mutation proofs were applied, confirmed in source, run red, restored,
and rerun green:

1. Removing the VIP-list branch fails the captured `0x80010109` test at
the exact code-block assertion.
2. Using raw `BeginAddress` fails the funclet identity test.
3. Using `startVIP` as the loaded-image base fails the GC/unwind test
with a read at `0x80010081` instead of the loaded image.
4. Raising the reader budget from 65,536 to 65,537 makes the budget+1
test fail at its read-boundary assertion (highest node index 65,536
instead of 65,535); restoring the budget returns the suite to green.
5. Replacing WASM filter-entry containing-function resolution with raw
`FilterOffset == funcletStartOffset` comparison makes the filter
regression fail with expected `true` and actual `false`.

The finite list cutoff is an intentional diagnostic-reader resource
policy, not a native registration limit or a claim that an over-budget
list is corrupt.

> [!NOTE]
> This pull request description was generated with GitHub Copilot.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing added a commit that referenced this pull request Sep 29, 2026
…rtual IPs (#134756)

> [!IMPORTANT]
> Stacked on #134754 (targets its branch). Only the commits above
#134754's head belong to this PR; retarget to `main` once #134754
merges.

## Problem

On `FEATURE_PORTABLE_ENTRYPOINTS` targets (WebAssembly), a method's
entry point slot holds a `PortableEntryPoint` address. Native maps it
before handing it to diagnostics
(`GetInterpreterCodeFromEntryPointIfPresent` /
`GetDiagnosticCodeStartFromEntryPoint` in `precode.cpp`), but the cDAC
returned the raw address. SOS values such as
`DacpMethodDescData.NativeCodeAddr` and rejit `NativeCodeAddr`, plus the
DBI/`ClrDataMethodInstance` paths, therefore reported an address that
doesn't resolve through `ExecutionManager` for both interpreted and R2R
methods.

## Change

Adds `IExecutionManager.GetDiagnosticCodeStartFromEntryPoint`, which
mirrors the native `GetDiagnosticCodeStartFromEntryPoint`:

- **Without portable entrypoints:** delegates to
`PrecodeStubs.GetInterpreterCodeFromInterpreterPrecodeIfPresent`.
Behavior is unchanged.
- **With portable entrypoints:**
1. Returns the address unchanged if it lies in a code range. This is the
native `FindCodeRange` check and includes Wasm R2R virtual-IP ranges
from #133917.
  2. Maps interpreted methods to `MethodDesc::m_interpreterCode`.
3. Maps native R2R methods from the function-table index in
`PortableEntryPoint._pActualCode` to the synthetic virtual IP, stepping
back past funclet entries. This matches
`ExecutionManager::GetWasmVirtualIPFromFunctionTableIndex`. As in native
code, it applies only to the method's own (temporary) entry point, and
only when the entry point doesn't prefer the interpreter.

The mapping lives in ExecutionManager rather than PrecodeStubs because
ExecutionManager owns the virtual-IP ranges and R2R lookup.
ExecutionManager already depends on PrecodeStubs, so this adds no
contract cycle.

Function-table-index resolution moves into
`ExecutionManagerHelpers.WasmFunctionTableIndexLookup`, which now bounds
the list walk and detects cycles, like the virtual-IP list walk. The
stack walk's `WasmR2RInfo` becomes a thin wrapper over it.

The Legacy SOS/DBI callers (`SOSDacImpl`, `ClrDataMethodInstance`,
`DacDbiImpl`) now call the new API. `DacDbiImpl.EnumerateAsyncLocals`
also maps its code address before querying async debug info. The native
DAC's `EnumerateAsyncLocals` gets the matching
`GetInterpreterCodeFromEntryPointIfPresent` mapping (as
`GetMethodVarInfo` already does), so the debug-build cDAC/DAC
cross-check stays consistent.

### Data descriptors

- `PortableEntryPoint.ActualCode` and `PortableEntryPoint.Flags` (only
under `FEATURE_PORTABLE_ENTRYPOINTS`).
- `MethodDesc.InterpreterCode` (only under `FEATURE_INTERPRETER`).
- The contract relies on `kPrefersInterpreterEntryPoint` and
`INTERPRETER_CODE_POISON`; the native side now has `[cDAC]` comments
marking that dependency.

### Interaction with #133890

#133890 adds `TryGetFunctionIdentity` and `TryIsFunclet` to
`WasmR2RInfo`. Whichever PR lands second should add those two methods to
`WasmFunctionTableIndexLookup` and have `WasmR2RInfo` forward to them.
The `FunctionTableIndexRange*` descriptor meanings here already use
#133890's exact wording, so that JSON should merge cleanly.

## Validation

- cDAC: `./build.sh -s tools.cdac+tools.cdactests -c Debug -test`
passes: 3196 unit tests (17 new), usage tests (contract cycles and
generated docs up to date), and generator tests.
- New ExecutionManager tests cover:
- interpreted, R2R, funclet, poison, prefers-interpreter,
not-own-entry-point and unknown-index cases;
- an end-to-end check that the resolved virtual IP maps back to the
MethodDesc through `GetCodeBlockHandle`;
- readable `PortableEntryPoint`-shaped bytes inside a registered code
range staying unchanged. This test fails if the range check is removed.
  - a cyclic function-table range list;
  - the non-portable delegation path.
- CoreCLR `clr.runtime` builds for osx-arm64 Debug (with
`FEATURE_INTERPRETER`) and browser-wasm Debug. The generated wasm
descriptor has `PortableEntryPoint {ActualCode@0, MethodDesc@4,
Flags@12}`.
- A new `DacDbiImplTests` test covers `EnumerateAsyncLocals` mapping for
both the code-address and MethodDesc paths. It fails without the fix.
- cDAC dump tests (osx-arm64, local runtime): 260 passed, 0 failed. The
746 skips are net10.0 configurations plus by-design skips (Windows-only
COM debuggees, and dump types a debuggee doesn't produce).
- CoreCLR `clr.runtime` Release (osx-arm64) builds with the
`dacdbiimpl.cpp` change.
- Not run: any check against a live wasm target.

Fixes #134753

> [!NOTE]
> This PR description was generated with GitHub Copilot.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing added a commit that referenced this pull request Sep 29, 2026
…rtual IPs (#134827)

This replaces #134756, which was merged into #134754's branch by mistake
and reverted there. The change is otherwise identical (cherry-picked
onto `main`).

## Problem

On `FEATURE_PORTABLE_ENTRYPOINTS` targets (WebAssembly), a method's
entry point slot holds a `PortableEntryPoint` address. Native maps it
before handing it to diagnostics
(`GetInterpreterCodeFromEntryPointIfPresent` /
`GetDiagnosticCodeStartFromEntryPoint` in `precode.cpp`), but the cDAC
returned the raw address. SOS values such as
`DacpMethodDescData.NativeCodeAddr` and rejit `NativeCodeAddr`, plus the
DBI/`ClrDataMethodInstance` paths, therefore reported an address that
doesn't resolve through `ExecutionManager` for both interpreted and R2R
methods.

## Change

Adds `IExecutionManager.GetDiagnosticCodeStartFromEntryPoint`, which
mirrors the native `GetDiagnosticCodeStartFromEntryPoint`:

- **Without portable entrypoints:** delegates to
`PrecodeStubs.GetInterpreterCodeFromInterpreterPrecodeIfPresent`.
Behavior is unchanged.
- **With portable entrypoints:**
1. Returns the address unchanged if it lies in a code range. This is the
native `FindCodeRange` check and includes Wasm R2R virtual-IP ranges
from #133917.
  2. Maps interpreted methods to `MethodDesc::m_interpreterCode`.
3. Maps native R2R methods from the function-table index in
`PortableEntryPoint._pActualCode` to the synthetic virtual IP, stepping
back past funclet entries. This matches
`ExecutionManager::GetWasmVirtualIPFromFunctionTableIndex`. As in native
code, it applies only to the method's own (temporary) entry point, and
only when the entry point doesn't prefer the interpreter.

The mapping lives in ExecutionManager rather than PrecodeStubs because
ExecutionManager owns the virtual-IP ranges and R2R lookup.
ExecutionManager already depends on PrecodeStubs, so this adds no
contract cycle.

Function-table-index resolution moves into
`ExecutionManagerHelpers.WasmFunctionTableIndexLookup`, which now bounds
the list walk and detects cycles, like the virtual-IP list walk. The
stack walk's `WasmR2RInfo` becomes a thin wrapper over it.

The Legacy SOS/DBI callers (`SOSDacImpl`, `ClrDataMethodInstance`,
`DacDbiImpl`) now call the new API. `DacDbiImpl.EnumerateAsyncLocals`
also maps its code address before querying async debug info. The native
DAC's `EnumerateAsyncLocals` gets the matching
`GetInterpreterCodeFromEntryPointIfPresent` mapping (as
`GetMethodVarInfo` already does), so the debug-build cDAC/DAC
cross-check stays consistent.

### Data descriptors

- `PortableEntryPoint.ActualCode` and `PortableEntryPoint.Flags` (only
under `FEATURE_PORTABLE_ENTRYPOINTS`).
- `MethodDesc.InterpreterCode` (only under `FEATURE_INTERPRETER`).
- The contract relies on `kPrefersInterpreterEntryPoint` and
`INTERPRETER_CODE_POISON`; the native side now has `[cDAC]` comments
marking that dependency.

### Interaction with #133890

#133890 adds `TryGetFunctionIdentity` and `TryIsFunclet` to
`WasmR2RInfo`. Whichever PR lands second should add those two methods to
`WasmFunctionTableIndexLookup` and have `WasmR2RInfo` forward to them.
The `FunctionTableIndexRange*` descriptor meanings here already use
#133890's exact wording, so that JSON should merge cleanly.

## Validation

- cDAC: `./build.sh -s tools.cdac+tools.cdactests -c Debug -test`
passes: 3196 unit tests (17 new), usage tests (contract cycles and
generated docs up to date), and generator tests.
- New ExecutionManager tests cover:
- interpreted, R2R, funclet, poison, prefers-interpreter,
not-own-entry-point and unknown-index cases;
- an end-to-end check that the resolved virtual IP maps back to the
MethodDesc through `GetCodeBlockHandle`;
- readable `PortableEntryPoint`-shaped bytes inside a registered code
range staying unchanged. This test fails if the range check is removed.
  - a cyclic function-table range list;
  - the non-portable delegation path.
- CoreCLR `clr.runtime` builds for osx-arm64 Debug (with
`FEATURE_INTERPRETER`) and browser-wasm Debug. The generated wasm
descriptor has `PortableEntryPoint {ActualCode@0, MethodDesc@4,
Flags@12}`.
- A new `DacDbiImplTests` test covers `EnumerateAsyncLocals` mapping for
both the code-address and MethodDesc paths. It fails without the fix.
- cDAC dump tests (osx-arm64, local runtime): 260 passed, 0 failed. The
746 skips are net10.0 configurations plus by-design skips (Windows-only
COM debuggees, and dump types a debuggee doesn't produce).
- CoreCLR `clr.runtime` Release (osx-arm64) builds with the
`dacdbiimpl.cpp` change.
- Re-validated after cherry-picking onto `main`: `./build.sh clr -c
Debug` (osx-arm64) builds, and `./build.sh -s tools.cdac+tools.cdactests
-c Debug -test` passes (3196 unit tests, 46 generator tests, 4 usage
tests; 0 failed).
- Not run: any check against a live wasm target.

Resolves #134753

> [!NOTE]
> This PR description was generated with GitHub Copilot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing added a commit that referenced this pull request Sep 30, 2026
## Summary

Fixes a GC hole during exception dispatch in Wasm R2R code. It shows up
in CI as a rare `SanityCheck()` assert in nested EH tests on the
browser-wasm R2R leg, for example `Test_throwinfinallynestedintry_30`.

## Root cause

Wasm R2R code has no interruptible ranges, and it reports every GC ref
on the frame as an untracked (pinned) slot. A method and its funclets
share those frame slots.

When an exception is thrown from a funclet, that funclet's frame is
`ExecutionAborted`. `GcInfoDecoder::EnumerateLiveSlots` finds no
interruptible range covering the offset and returns without reporting
anything, including the untracked slots. The parent frames (the caller
of an out-of-line finally, and the main method body) are skipped as
already reported by the funclet.

So during dispatch nothing reports the method's untracked slots. A GC in
that window can free an object still held in one of them. In the repro
it's a boxed `Int32`. The next time the slot is reported, it points at
freed memory and hits `SanityCheck()`.

Other targets avoid this in the JIT.
`CodeGenInterface::setFramePointerRequiredEH` forces every method with
EH to be fully interruptible, because `EnumGcRefs` only reports slots in
aborted frames that are fully interruptible. Wasm is explicitly excluded
there, and Wasm R2R code can't be fully interruptible.

## Fix

Add a `HAS_INTERRUPTIBLE_RANGES` trait to each `GcInfoEncoding`, `false`
only for `Wasm32GcInfoEncoding`.

- `EnumerateLiveSlots` skips the interruptible-range handling for that
encoding and reports only untracked slots outside safe points, including
for aborted frames.
- The count of interruptible ranges is no longer serialized for that
encoding. The encoder, the runtime decoder, the cDAC decoder and R2RDump
all skip it, and `DefineInterruptibleRange` asserts it's never used for
that encoding.
- There's no R2R version bump: Wasm hasn't shipped, so Wasm-specific
format changes don't need one.

The gate is on the encoding rather than `TARGET_WASM`: on Wasm the
interpreter's GC info goes through the same decoder, and interpreter
code does define an interruptible range. Native targets are unchanged.

The cDAC `GCInfoDecoder` mirrors the change through the same trait
(default `true`), and `docs/design/datacontracts/GCInfo.md` documents
it. `main` has no Wasm32 GC info traits in the cDAC yet; #133890 adds
them.

I fixed this in the decoder rather than having the JIT declare Wasm
methods interruptible over their whole range. That claim isn't true for
Wasm, and other checks rely on it (GC stress, the safe-point asserts).

## Size

On browser-wasm `System.Private.CoreLib.wasm` (CI crossgen options),
omitting the count saves 1,088 bytes (0.003%). A GC info blob only
shrinks when the 2 saved bits cross a byte boundary (11,606 of 58,253
methods), and identical blobs are shared (5,331 distinct unwind entries
across 60,323 methods and funclets).

## Validation

I ran the CI Helix payload for `Methodical_d1` from build 1613981
locally, with crossgen2 built from this branch and a browser-wasm
`corerun` built with and without the fix. For the final format I
recompiled CoreLib and the test assemblies with this branch's crossgen2.

| | Without fix | With fix |
|---|---|---|
| `Test_throwinfinallynestedintry_30`, `DOTNET_GCStress=0x1` | same
`SanityCheck()` assert as CI, every run | pass |
| `throwincascadedexcept_d`, `throwincascadedexceptnofin_d`,
`DOTNET_GCStress=0x1` | assert, then timeout | pass |
| `Methodical_d1` merged runner, no stress | 128 passed | 128 passed,
same results |

- Native osx-arm64 and browser-wasm checked builds pass.
- R2RDump and the cDAC unit tests pass (3179/3179).
- The final format gives the same no-stress results as a control that
still writes and reads the count.
- All 84 `Methodical_d1` assemblies, R2R-compiled with the checked Wasm
JIT, never hit the new encoder assert.
- In every full run, the same 18 out-of-process tests fail because my
local runner doesn't handle out-of-process tests.
- The final revert of the version bump only restores `readytorun.h` and
the two `ModuleHeaders` files to `main`; it wasn't rebuilt.

With this fix, the full `Methodical_d1` suite under
`DOTNET_GCStress=0x1` was clean in 3 of 4 runs. The 4th hit a
`RawGetMethodTable()` assert in `throw_SEH`. That's a separate,
pre-existing hole, #134777: `CallDescrWorkerInternal` leaves the call's
arguments unreported while `DoPrestub` runs. It reproduces identically
with and without this change and is fixed in #134779. With both fixes,
the full suite under stress passed 4 of 4 runs (126 passed), in a run
before the count was removed from the format.

I also checked native (osx-arm64, release 11.0 rc1 runtime, FullOpts
JIT) with a small app. An object is held only in an address-exposed
local, a `finally` throws, and the same method catches it with a filter
that forces a GC. The object stayed alive 20/20 times, and JitDisasm
shows the method as `; fully interruptible`, as
`setFramePointerRequiredEH` requires.

CI doesn't run GC stress on the Wasm R2R leg, which is why this showed
up as a ~0.5% flake rather than a hard failure.

Resolves #134768

> [!NOTE]
> This PR description was drafted with GitHub Copilot.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-Diagnostics-cdac

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant